Skip to content

Give the tools an 8MB stack on msvc - #2815

Merged
sbc100 merged 1 commit into
WebAssembly:mainfrom
Nishuuzz:fix/wast-parser-nesting-depth
Aug 11, 2026
Merged

Give the tools an 8MB stack on msvc#2815
sbc100 merged 1 commit into
WebAssembly:mainfrom
Nishuuzz:fix/wast-parser-nesting-depth

Conversation

@Nishuuzz

@Nishuuzz Nishuuzz commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

wat2wasm faults on deeply nested input instead of reporting anything. On Windows it exits 0xC00000FD (STATUS_STACK_OVERFLOW); on Linux with a small stack it segfaults. Same for wast2json and wat-desugar, since they share the parser. That's #2377.

The parser is recursive descent, so every nesting level costs a few stack frames — measured at roughly 600 bytes per level for a gcc debug build, and msvc debug is several times that.

This started out as a nesting limit, but that was the wrong fix: real modules nest deeply, because a switch lowers to a br_table wrapped in one block per case. Measuring the max control depth of real toolchain output:

webp_enc (emscripten)     29
resvg (wasm-pack)        180
sqlite via sql.js (emsc) 282
esbuild (go)            3457

Any limit low enough to fit msvc's 1MB default would have rejected sqlite and esbuild, which parse fine elsewhere today.

The stack size is what actually matters here, so that's all this does now. With 8MB a debug build handles 14000 levels, and esbuild's own text reads back:

stack 1024 KB  CRASH
stack 8192 KB  ok

That's the whole 1.9GB .wat with no limit in the parser at all.

This doesn't make the recursion unbounded-safe — deep enough input will still exhaust any stack. Doing that properly means not recursing at all, the way wasm-tools' wast crate does, which is a much bigger change and doesn't need to block this.

Fixes #2377.

Comment thread include/wabt/wast-parser.h Outdated
Comment thread include/wabt/wast-parser.h Outdated
Comment thread include/wabt/wast-parser.h Outdated

@sbc100 sbc100 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, I wonder if real world module will blow through this limit or not?

@Nishuuzz

Nishuuzz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 505d3fa addressing all three comments — and CI caught a real problem with the original limit, so the value has changed.

1000 was too high. build (windows-latest) exited 0xC00000FD (STATUS_STACK_OVERFLOW) before the limit was ever reached: msvc debug frames are large enough to blow the default 1MB stack first. Ironic, but it is the exact failure this PR is about, so it had to move. (The macOS failure alongside it was just fail-fast cancellation — it had passed.)

> I wonder if real world module will blow through this limit or not?

I measured instead of guessing. Decoding the code section of every module in the spec testsuite plus wabt's own tests — 18952 modules:

nesting depth
max 80 (test/regress/regress-9, a deliberate stress test)
p99 3
p90 1
median 0

Real control flow is very shallow; the only deep thing in the tree is a test written to be deep. The outlier in the wild is the generated code in #2377, which was thousands deep — that one still gets an error, but an error rather than a segfault.

So 128 sits ~1.6x above the deepest module I can find anywhere, and roughly 4x under where msvc debug falls over (~600 bytes/level measured on gcc debug, and msvc is worse). If you would rather trade margin for headroom, raising it is a one-line change — I just would not go near 1000 again without also growing the stack, which is what #2748 does.

One consequence worth naming: BinaryReaderIR::kMaxNestingDepth is 16384, so a binary nested deeper than 128 can now be read but not round-tripped back through the text parser. That gap already existed in the other direction (the text parser used to crash where the binary reader errored cleanly); this makes both ends report an error, just at different depths.

Full test suite is green against a debug ASAN/UBSAN build with all submodules checked out, and scripts/clang-format-diff.sh is clean.

Comment thread include/wabt/wast-parser.h Outdated
// the default 1MB stack. This leaves room for that and is still well above
// what real modules use: across the spec testsuite and wabt's own tests
// (~19k modules) the deepest is 80 and the 99th percentile is 3.
static constexpr int kMaxNestingDepth = 128;

@sbc100 sbc100 Aug 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec tests are not really real world modules though.

I wonder what kind of nesting can be produced, for example, by wasm-opt on large real-world projects.

I'm also curious of real world engines enforce any kind of nesting limit like this. It looks like a limit is defined in https://www.w3.org/TR/wasm-core-2/#a3-implementation-limitations but I can't see what the value is.

@Nishuuzz

Nishuuzz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

You were right to push on that, the spec tests turn out to say almost nothing useful here. I went and measured real toolchain output instead, taking the max control depth out of wasm2wat's own label annotations:

webp_enc (emscripten)     29
resvg (wasm-pack)        180
sqlite via sql.js (emsc) 282
esbuild (go)            3457

So 128 was too low. Round-tripping sqlite through wasm2wat needs a limit of 320 and resvg needs 192, both of which I would have broken. I have raised it to 512, which covers those with some room and is still half of the 1000 that overflowed msvc debug in CI on this PR.

esbuild is the one worth dwelling on. At 3457 it is deeper than any build I have survives, so wat2wasm already cannot read wasm2wat's own output for it — it faults on Windows and under ASAN. That is this bug on a real module rather than a synthetic one, which is nice motivation, but it also means no limit can rescue that case: 3457 levels is several MB of frames and the tightest thing we build for is a 1MB stack. All the limit does there is turn the segfault into a message.

On the spec link: A3 does list "the nesting depth of structured control instructions" as a dimension implementations may restrict, but it also says up front that where restrictions take the form of numeric limits, "no minimum requirements are given, nor are the limits assumed to be concrete, fixed numbers". So there is deliberately no value in there to copy.

Engines do not seem to have one either. V8's wasm-limits.h has the ones wabt already mirrors (function size, 50000 locals, 1000 params/returns, br_table 65520) but nothing for control depth. That follows from their decoders being iterative — nesting costs heap rather than stack, so there is nothing to defend against. It is specifically recursive text parsers that need this. wasm-tools is the closest comparison and they went structural instead: the wast crate deliberately avoids call-stack recursion when parsing expressions, on the grounds that it is parsing user input that risks blowing the stack, and exposes parser.depth() so callers can impose a limit themselves.

That is probably the honest long-term answer for wabt too, if deeply nested modules should actually parse rather than just fail cleanly. Short of that, the limit is capped by the smallest stack we build for, so I would not go much above 512 without raising the stack as well — which is what #2748 is doing on the Windows side. Happy to pick a different number if you have a preference.

@Nishuuzz

Nishuuzz commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

CI came back and 512 still faults on windows, so there is a second half to this that I had wrongly assumed was optional.

msvc's default stack is 1MB. That is not enough to reach the limit, so deeply nested input dies before the parser gets to say anything — the check never runs. Picking a limit that does fit in 1MB is not really a way out either: it would have to be somewhere under 256, and real modules need more than that, so wat2wasm would start rejecting input that parses fine today on Linux and macOS. Trading a crash on Windows for a regression everywhere else seemed like the wrong deal.

So I have added the linker flag to ask for the 8MB stack that Linux and macOS already give us. That is the same change #2748 makes, for the same reason — I have said so in the commit and in the comment next to it. If you would rather land #2748 first I will happily drop it from here and rebase; it is one target_link_options line and the conflict should be trivial either way.

That does mean the two pieces are less independent than I claimed earlier: the limit stops the recursion running off the end of the stack, but on msvc it needs the bigger stack to be reachable at all. Sorry for the earlier framing, I did not have the Windows data then.

For reference, where the numbers landed: 512 covers sqlite (needs 320) and resvg (needs 192) with room, and sits well under the ~13000 levels an 8MB stack allows at the ~600 bytes per level I measured. macOS passed 512 before it got cancelled by the windows failure, so it was only ever msvc that objected.

Comment thread CMakeLists.txt Outdated
# The wast parser is recursive descent, and msvc's default 1MB stack is
# not enough to reach WastParser::kMaxNestingDepth, so deeply nested
# input faults before the parser can report it. Ask for the 8MB that
# Linux and macOS give us by default. #2748 does the same thing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to mention the other PR number here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@sbc100

sbc100 commented Aug 9, 2026

Copy link
Copy Markdown
Member

I think its fine to land the stack size bump as a part of this change since #2748 includes other changes that we still working through

@sbc100

sbc100 commented Aug 9, 2026

Copy link
Copy Markdown
Member

Can you suggest a more concise message to be used for the final squashed commit?

@Nishuuzz

Copy link
Copy Markdown
Contributor Author

Reference dropped, thanks — and good to know about #2748.

For the squash message:

Limit instruction nesting depth in the wast parser

The parser is recursive descent, so deeply nested but otherwise valid
text overflowed the stack rather than reporting an error. Add a nesting
limit, and raise the msvc stack to 8MB since its 1MB default is too
small to reach that limit.

Fixes #2377.

@sbc100
sbc100 enabled auto-merge (squash) August 10, 2026 20:57
@sbc100
sbc100 disabled auto-merge August 10, 2026 20:57
@sbc100

sbc100 commented Aug 10, 2026

Copy link
Copy Markdown
Member

After chatting with @tlively about about it does seems like real world module can have a very deep level nesting dure the br_table instruction. Apparently a swith/case in C can lower to a br_table with a nesting depth propositional the number of cases.

So I'm not sure what to do here.. maybe the real solution is to refactor to avoid the use of the native stack? But that seems like a lot of work. I'm temped to maybe just do nothing (aside from maybe increate the windows stack size).

Is there much point exiting early before full stack exhaustion is reached?

The wast parser is recursive descent, so nesting depth comes out of the
stack, and real modules nest deeply -- a switch lowers to a br_table
wrapped in one block per case. esbuild reaches 3457 levels, which msvc's
1MB default cannot parse: wat2wasm faults rather than reporting anything.

Ask for the 8MB that Linux and macOS already give us. With that, reading
esbuild's own text back works, and a debug build handles 14000 levels.

Fixes WebAssembly#2377.
@Nishuuzz
Nishuuzz force-pushed the fix/wast-parser-nesting-depth branch from 9a40096 to f1501ff Compare August 11, 2026 10:33
@Nishuuzz Nishuuzz changed the title Limit instruction nesting depth in the wast parser Give the tools an 8MB stack on msvc Aug 11, 2026
@Nishuuzz

Copy link
Copy Markdown
Contributor Author

That matches what I am seeing. In esbuild the deep part is exactly that shape, long runs of nested blocks wrapping br_tables, and it bottoms out at 3457.

On whether exiting early is worth it: for real modules, no. With no nesting limit at all, a debug build handles 14000 nested blocks on an 8MB stack, and esbuild's own text reads back fine:

stack 1024 KB  CRASH
stack 8192 KB  ok

That is the whole 1.9GB .wat, with nothing in the parser stopping it. So the stack size is what makes real input work, not the limit. Mine at 512 was tuned to fit msvc's 1MB default, which is exactly the constraint the stack bump removes, so it was solving the wrong half.

I have dropped the limit. The PR is now just the linker flag, and I have retitled it to match.

For the record on what a limit would have bought, since it is not nothing: libwabt gets embedded, and stack exhaustion is an uncatchable crash in whatever process links it, while an error can be handled. It would also make behaviour the same across build configurations rather than depending on how much stack you happen to have. But that only holds if the number sits far above anything real, and as a tuned value it is just a way to reject valid modules, which is the thing you and @tlively are pointing at. Not worth it here.

This does not make the recursion safe in general, to be clear. Deep enough input still exhausts whatever stack you give it. Doing that properly means not using the native stack, the way the wast crate does, and I agree that is a lot of work and should not hold up the 8MB.

Also correcting myself from earlier in the thread: I said esbuild could not be read at all. With 8MB it can. That was only true at the smaller stack.

@sbc100

sbc100 commented Aug 11, 2026

Copy link
Copy Markdown
Member

Note that if we embed libwabt inside another executable then the approach decribed in the initial PR also doesn't work because we (a) we don't know how deep we are in the stack when we start parsing and (b) We don't know how much stack space the embedding program was linking with. So another approach would be needed anyway I think, something that can explicitly know the bounds of the stack I guess? But i'm not aware of portable solution there.

@sbc100
sbc100 merged commit 1efe420 into WebAssembly:main Aug 11, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wat2wasm segfaults on .wat file with many nested if statements

2 participants